iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Software Development

Effect 魔法:打造堅不可摧的應用程式系列 第 24

23. Effect 應用 2 :用 orpc 與 Effect 打造強韌的 API 介面

  • 分享至 

  • xImage
  •  

這篇要來看 Effect 在後端又可以怎麼樣的使用,這次我們會搭配 orpc 這個 RPC 的套件來一起使用, orpc 可以幫助我們寫出 type-safety 的 API ,同時還有對各種前後端都很好的支援,如果你對 orpc 本身有興趣,也可以看看我放在 substack 上的文章介紹怎麼用 orpc 搭配 OpenAPI spec

還記得之前提過我有在我自己的開源專案使用 Effect ,使用自訂的 runtime 共享一個 mutex 嗎?這次就來介紹這個部份吧,我們來寫個 todo app 當範例,雖然 todo app 聽起來好像有點老套,但我個人認為它是個很好的題目用來展示前後端的功能

這次的設計目標是一個可以將 todo 存在後端的 app ,我們會刻意的單純使用檔案做為儲存的方法

這次完整的程式碼範例在 https://github.com/DanSnow/ithelp-2025-ironman-sample-codes/tree/main/effect-todo-app

後端實作

這次的專案是使用 create-start-app 建立的 TanStack Start 的專案,建立時記得選 tailwindcss, shadcn, tanstack query 與這次的主角之一 orpc ,這樣你就會很快的有個專案,而且已經內建了一個 todo app 的範例了,不過 todo 列表是存在記憶體中的,重開就不見了,我們先改成用檔案儲存吧

首先先把檔案的存取,還有 todo 的操作都包裝成 service ,首先是檔案的存取,也就是持久化的部份

// src/services/StorageService.ts

import { readFile, writeFile } from "node:fs/promises";
import { Effect, pipe } from "effect";
import { z } from "zod";
import { TodoSchema } from "@/orpc/schema";

const TodosSchema = z.array(TodoSchema);

const FILE_NAME = "todos.json";

type Todos = z.infer<typeof TodosSchema>;

export class StorageService extends Effect.Service<StorageService>()(
  "Storage",
  {
    accessors: true,
    effect: Effect.gen(function* () {
      return {
        loadTodos: pipe(
          // 讀取檔案
          Effect.tryPromise(() => readFile(FILE_NAME, "utf-8")),
          // 這邊使用的是 tryMap ,簡單來說就是 try + map 的合體
          Effect.tryMap({
            try: (content) => {
              const json = JSON.parse(content);
              return TodosSchema.parse(json);
            },
            catch: (error) => error as Error,
          }),
          // 若有任何的錯誤就回傳空陣列,包含檔案找不到,格式不對等
          Effect.catchAll(() => Effect.succeed([])),
        ),
        // 寫入 todo
        saveTodos: (todos: Todos) =>
          Effect.promise(() => writeFile(FILE_NAME, JSON.stringify(todos))),
      };
    }),
  },
) {}

檔案處理的部份已經包好一個 service 了,再來是比較高階的操作的 service

// src/services/TodoService.ts

import { Array, Effect, pipe } from "effect";
import type { Todo } from "@/orpc/schema";
import { StorageService } from "./StorageService";

export class TodoService extends Effect.Service<TodoService>()("Todo", {
  accessors: true,
  dependencies: [StorageService.Default],
  effect: Effect.gen(function* () {
    const { loadTodos, saveTodos } = yield* StorageService;
    return {
      todos: loadTodos,
      // 讀取檔案,增加 todo 後再寫回去
      addTodo: (name: string) =>
        pipe(
          loadTodos,
          Effect.map((todos): Todo[] =>
            Array.append(todos, {
              id: todos.length,
              name,
            }),
          ),
          Effect.flatMap((todos) => saveTodos(todos)),
        ),
    };
  }),
}) {}

再來我們準備後端用的 runtime

// src/runtime.ts

import { ManagedRuntime } from "effect";
import { TodoService } from "./services/TodoService";

export const BackendRuntime = ManagedRuntime.make(TodoService.Default);

最後將 orpc 內的 handler 換成 todo 的操作方法就行了

// src/orpc/router/todos.ts

import { os } from "@orpc/server";
import { z } from "zod";
import { BackendRuntime } from "@/runtime";
import { TodoService } from "@/services/TodoService";

export const listTodos = os.input(z.object({})).handler(() => {
  return BackendRuntime.runPromise(TodoService.todos);
});

export const addTodo = os
  .input(z.object({ name: z.string() }))
  .handler(({ input }) => {
    return BackendRuntime.runPromise(TodoService.addTodo(input.name));
  });

到這邊就完成了,話說這邊你可以看到 orpc 是如何定義 API 的,我們可以指定輸入與使用 zod 驗證輸入的參數,我們可以看一下範例內的 UI 是怎麼串接後端的

// src/routes/index.tsx

// 以下是位於 component 中的部份程式碼
const { data, refetch } = useQuery(
  orpc.listTodos.queryOptions({
    input: {},
  }),
);

const [todo, setTodo] = useState("");

const { mutate: addTodo } = useMutation(
  orpc.addTodo.mutationOptions({
    onSuccess: () => {
      refetch();
      setTodo("");
    },
  }),
);

const submitTodo = useCallback(() => {
  addTodo({ name: todo });
}, [addTodo, todo]);

這邊是使用 orpc 與 tanstack query ,你可以看到我們可以直接用 orpc 自動產生的 queryOptionsmutationOptions 來提供給 tanstack query ,而且這邊也都有完整的型態檢查,這讓我們的 API 呼叫跟自己寫 fetch 沒有型態檢查等等的比起來安全的很多

看完了我們就可以直接用範例內的 UI 操作看看了,不過目前的版本有個問題,你有看出來嗎?

data race

在範例中,我們使用了檔案做為儲存的媒介,但這造成了一個問題,那就是我們的 addTodo 不是原子的操作,也就是說如果同時執行兩次 addTodo 的話,有可能會發生競爭狀態,造致檔案中的內容不是我們預期的,我們實際來測試看看吧

測試需要同時發送多個 http request ,我們直接使用 http 的 benchmark 工具 oha 幫我們做到吧,安裝後執行以下指令, oha 預設會發送 200 個 request ,這已經足夠我們測試用了

$ oha -m POST -d '{"json":{"name":"hello"}}' -T 'application/json' 'http://localhost:3000/api/rpc/addTodo'

然後我們去看看 todos.json ,理論上裡面應該要有 200 個 todo ,但實際上:

[{"id":0,"name":"hello"}]

沒錯!只有一個,我們來看怎麼處理吧

處理競爭狀態

這邊我們使用鎖來幫檔案存取的部份加上保護, Effect 其實就內建了 Semaphore 可以使用, Seamphore 是一種可以設定最多允許 n 個操作同步進行的同步機制,當一個動作開始進行時,就從 Semaphore 中減 1 ,完成時加 1 ,若 Semaphore 為 0 時則需要等待其它的操作完成

我們平常常看到的 mutex 可以當作是一種只允許 1 個操作的特殊 Semaphore ,不過 Effect 裡只有 Semaphore 我們就拿來用了,要使用很簡單:

// src/services/TodoService.ts

// 這段是 service 內的程式碼
// 使用 Effect.makeSemaphore 建立一個 Semaphore ,這個 Seamphore 只允許 1 個操作
const mutex = yield* Effect.makeSemaphore(1);

return {
  todos: loadTodos,
  addTodo: (name: string) =>
    pipe(
      loadTodos,
        Effect.map((todos): Todo[] =>
          Array.append(todos, {
            id: todos.length,
            name,
          }),
        ),
        Effect.flatMap((todos) => saveTodos(todos)),
        // 指定這個 Effect 需要從 Seamphore 扣除 1 ,這樣就能保證同時只會有一個在執行了
        mutex.withPermits(1),
      ),
};

我們到這邊可以再執行一次 oha 確認資料是不是真的有正常的變成 200 筆,那我們這次的實作就到這邊了,不過 Effect 帶來的好處可不是只有很好設定與使用 Seamphore 而已,在上面我們還將存取的動作與 todo 的操作都拆成 service ,這帶來的好處有:

  1. 更容易測試: 你可以 mock 持久化的部份來單獨測試資料的操作
  2. 容易抽換實作: 資料操作的部份有獨立的介面,之後要換成 db 也只需要確保介面不動就行

這些在後端都可以增加程式的正確與穩定性,下一篇要來介紹的是 log


上一篇
22. Effect 應用 1 : 如何在 React 中呼叫 Effect 的程式
下一篇
24. Effect logging
系列文
Effect 魔法:打造堅不可摧的應用程式25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言